返回文章列表

MySQL 执行流程:一条 SQL 是怎么从文本变成结果的

从客户端 SQL 到 InnoDB 返回记录,拆解连接器、解析器、优化器、执行器和存储引擎之间的协作。

MySQL 执行流程:一条 SQL 是怎么从文本变成结果的

你在客户端敲了一条 SELECT,0.5 秒后看到了结果。但这 0.5 秒里,MySQL 内部其实走了一条相当长的路。

很多人第一次学 MySQL 执行流程,会把它背成一串模块名:

连接器
-> 查询缓存
-> 解析器
-> 预处理器
-> 优化器
-> 执行器
-> 存储引擎

这串名字当然重要,但如果只背名字,很容易产生一个误会:好像 MySQL 只是把 SQL 语句按流水线传一遍。

真实情况更有意思。

一条 SQL 在客户端看来只是一行文本,但 MySQL 不能直接拿这行文本去磁盘里找数据。它至少要先回答几个问题:

  1. 这个客户端是谁,有没有权限?
  2. 这句话是不是合法 SQL?
  3. 里面的表和字段是否真的存在?
  4. 如果有多种查询方式,哪一种成本更低?
  5. 最终应该让哪个存储引擎去读哪一批数据?

所以 MySQL 执行流程真正解决的问题是:

如何把一段 SQL 文本,变成一条可执行、可优化、可和存储引擎协作的数据读取路径。

为了让这篇文章更好懂,我们固定一个例子:

CREATE TABLE product (
  id BIGINT PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  price DECIMAL(10, 2) NOT NULL,
  category_id BIGINT NOT NULL,
  KEY idx_name (name)
) ENGINE=InnoDB;

然后执行这条查询:

SELECT *
FROM product
WHERE id = 1;

从业务角度看,这是“查 id 为 1 的商品”。

从 MySQL 角度看,它要经历一条完整的路:

![06.MySQL 执行流程 图 1](./assets/06.MySQL 执行流程-mermaid-01.svg)

上图是一条 SQL 的完整旅程。从连接器确认身份,到解析器看懂语法,预处理器确认对象存在,优化器选最便宜的执行路径,执行器调用 InnoDB 接口,最后存储引擎从 B+ 树和数据页中把记录拿出来。

下面就沿着这条路,把一条 SELECT 语句走一遍。

一、故事要从客户端连接开始

MySQL 不是一个孤零零的函数调用。客户端想执行 SQL,第一步是先连上 MySQL 服务。

比如命令行里常见的连接方式是:

mysql -h127.0.0.1 -uroot -p

这一步背后先是网络连接。MySQL 基于 TCP 通信,所以客户端和服务端需要先建立 TCP 连接。连接建立之后,MySQL 的连接器开始工作。

连接器主要做三件事:

建立连接
-> 校验用户名和密码
-> 读取并保存当前用户权限

这里有一个容易忽略的细节:权限是在连接建立时读出来并保存的。

也就是说,如果一个用户已经连上了 MySQL,管理员后来修改了这个用户的权限,已经存在的连接不会立刻感知到权限变化。只有重新建立的新连接,才会使用新的权限。

这也解释了为什么线上排查权限问题时,不能只看权限表现在是什么样,还要注意应用侧是否复用了旧连接。

连接建立后,如果客户端长时间不发 SQL,这个连接会变成空闲连接。可以用:

SHOW PROCESSLIST;

查看当前连接状态。

空闲连接不会无限期保留。MySQL 有 wait_timeout 参数控制空闲连接最长能活多久。超过时间后,服务端会主动断开连接。

所以连接器这一层解决的是第一个问题:

MySQL 先确认“谁在说话”,再决定后面的 SQL 能不能继续执行。

二、查询缓存为什么后来被删掉了

连接建立后,客户端就可以发送 SQL。

在 MySQL 8.0 之前,SELECT 查询可能会先经过查询缓存。查询缓存的想法很直观:

把 SQL 文本当 key
把查询结果当 value
如果下一次一模一样的 SQL 又来了,就直接返回缓存结果

听起来很美好,但它有一个致命问题:数据库里的数据会变。

只要某张表发生更新,和这张表相关的查询缓存就会失效。对于更新频繁的业务表来说,缓存刚放进去,可能马上就因为一次写操作被清掉。

这会带来两个尴尬结果:

  • 命中率低,真正能复用的机会不多。
  • 维护缓存本身也有成本,反而拖慢系统。

所以 MySQL 8.0 已经移除了 Server 层的查询缓存。

这里要先区分清楚:

查询缓存不是 Buffer Pool。

查询缓存缓存的是“某条 SQL 的结果集”,属于 Server 层的旧机制。

Buffer Pool 缓存的是 InnoDB 的数据页,属于存储引擎层的核心机制。前者在 MySQL 8.0 被删除,后者仍然是 InnoDB 性能的基础。

因此,在今天理解 MySQL 执行流程时,可以把查询缓存当作历史知识:

MySQL 8.0 之前:连接器 -> 查询缓存 -> 解析器 ...
MySQL 8.0 之后:连接器 -> 解析器 -> 预处理器 ...

三、解析器:先把 SQL 文本看懂

跳过查询缓存后,MySQL 面对的仍然是一段文本:

SELECT *
FROM product
WHERE id = 1;

这段文本对人很好懂,但机器不能靠“看起来差不多”理解它。MySQL 必须先把它拆开。

解析器主要做两类工作。

第一类是词法分析(Lexical Analysis,将文本拆成关键字、标识符等词法单元的过程)。

它会识别出这段文本里的关键元素:

SELECT:查询关键字
*:查询所有列
FROM:指定数据来源
product:表名
WHERE:过滤条件
id:字段名
= 1:比较条件

第二类是语法分析(Syntax Analysis,检查词法单元组合是否符合 SQL 语法规则的过程)。

它会检查这段 SQL 是否符合 MySQL 的语法规则。比如你把 FROM 写成 FORM

SELECT *
FORM product
WHERE id = 1;

解析器就会在这一层报语法错误。

但这里有一个非常重要的边界:

解析器主要判断语法是否正确,不负责确认表和字段是否真的存在。

也就是说,下面这条 SQL 语法上是成立的:

SELECT *
FROM not_exists_table;

它的问题不是“语法错了”,而是“表不存在”。这类问题会在后面的预处理阶段继续检查。

所以解析器解决的是第二个问题:

MySQL 先把 SQL 从一段字符串,变成后续模块能理解的结构。

四、预处理器:确认表、字段和星号

解析完成后,MySQL 已经知道这是一条查询语句,也知道里面写了哪个表、哪个字段、什么条件。

但它还要继续确认:

product 表是否存在?
id 字段是否存在?
SELECT * 里的 * 到底代表哪些列?

这些工作由预处理器完成。

比如:

SELECT *
FROM test;

如果 test 表不存在,MySQL 会在这个阶段报出类似错误:

Table 'xxx.test' doesn't exist

再比如:

SELECT not_exists_column
FROM product;

如果 not_exists_column 字段不存在,也会在这个阶段被发现。

预处理器还会把:

SELECT *
FROM product;

里的 * 展开成实际列:

SELECT id, name, price, category_id
FROM product;

这一步看起来不起眼,但它让后面的优化器和执行器知道:最终到底要取哪些列。

所以预处理器解决的是第三个问题:

这条 SQL 不只是语法要对,还要能落到真实的数据对象上。

五、优化器:同一个结果,可能有很多条路

通过预处理后,SQL 已经合法,表和字段也都存在。

接下来 MySQL 面临一个更关键的问题:

怎么查最划算?

对这条语句来说:

SELECT *
FROM product
WHERE id = 1;

因为 id 是主键,优化器很容易判断:直接走主键索引最合适。

但实际查询往往没有这么简单。

比如:

SELECT *
FROM product
WHERE name = 'iPhone';

如果 name 上有索引,MySQL 可以考虑走 idx_name

如果没有索引,MySQL 可能只能全表扫描:

从第一行开始读
-> 判断 name 是否等于 iPhone
-> 不符合就跳过
-> 一直读到最后一行

再比如:

SELECT id
FROM product
WHERE name = 'iPhone';

如果 idx_name 是二级索引,InnoDB 的二级索引叶子节点里本来就保存了 name 和对应的主键 id。这时查询只要返回 id,可能不需要回表读取整行,这就是覆盖索引的机会。

优化器要做的事情,就是根据统计信息、索引情况、查询条件和返回列,估算不同方案的成本,然后选择一个执行计划。

可以用 EXPLAIN 看优化器选择了什么:

EXPLAIN
SELECT *
FROM product
WHERE id = 1;

重点先看几个字段:

  • type:访问类型,比如 constrefrangeALL。一般来说,ALL 表示全表扫描,成本通常更高。
  • key:实际使用了哪个索引。如果是 NULL,说明没有使用索引。
  • rows:优化器估算需要扫描多少行。
  • Extra:额外信息,比如 Using indexUsing index condition

这里要记住一个边界:

优化器不是证明数学最优,而是基于统计信息估算一个相对划算的执行计划。

统计信息不准、索引设计不合理、条件写法破坏索引,都可能让优化器选择的路线和你预期不同。

所以优化器解决的是第四个问题:

同样是拿到结果,MySQL 要先决定走哪条访问路径。

六、执行器:按计划向存储引擎要数据

优化器选好执行计划后,真正开始干活的是执行器。

执行器位于 Server 层。它自己不直接管理 InnoDB 的 B+ 树和数据页,而是按照执行计划调用存储引擎接口,让存储引擎去读记录。

可以把执行器理解成一个调度者:

优化器给出计划
-> 执行器按计划调用引擎接口
-> 存储引擎返回一行或一批记录
-> 执行器继续判断条件、组织结果
-> 返回给客户端

接下来用三种典型访问方式,把执行器和 InnoDB 的协作看清楚。

三种查询路径对比

上图展示了同一条 SQL 可能走的三种不同路径。主键查询走 B+ 树直达叶子页,最快。全表扫描要逐行判断,最慢。二级索引查询需要先找到主键,再回主键索引取整行,多走一步。优化器的工作就是评估这三条路的成本,选最划算的一条。

七、主键索引查询:最快的直达路线

先看最简单的语句:

SELECT *
FROM product
WHERE id = 1;

因为 id 是主键,且是等值查询,优化器通常会选择主键索引查询。

执行流程可以简化成:

执行器:我要 id = 1 的第一条记录
-> InnoDB:沿着主键 B+ 树定位到对应叶子页
-> InnoDB:在页内找到 id = 1 的记录
-> InnoDB:把整行记录返回给执行器
-> 执行器:判断 WHERE 条件满足
-> 执行器:把结果返回客户端

因为主键唯一,id = 1 最多只有一条记录。InnoDB 找到这一条之后,再往后读时就会知道没有更多记录,查询结束。

这里可以和索引那篇连起来理解:

主键索引的叶子节点保存整行数据,所以通过主键索引找到叶子节点后,就已经拿到了完整记录,不需要再回表。

这条路径很短:

Server 层确认执行计划
-> InnoDB 主键 B+ 树定位
-> 返回整行

这就是为什么主键等值查询通常很快。

八、全表扫描:没有路标时只能一行行看

再看另一条查询:

SELECT *
FROM product
WHERE category_id = 3;

假设 category_id 上没有索引,优化器很可能只能选择全表扫描。

全表扫描不是“出错”,而是一种访问路径。只是它比较笨重:

执行器:给我表里的第一条记录
-> InnoDB:返回第一条记录
-> 执行器:判断 category_id 是否等于 3
-> 符合就返回客户端,不符合就跳过
-> 执行器:给我下一条记录
-> InnoDB:返回下一条记录
-> 重复直到表被读完

这里有一个细节值得注意:

执行器和存储引擎的交互,很多时候是按记录逐步推进的。

客户端最后看到的结果像是“一次性出来一张表”,但 MySQL 内部是在不断读记录、判断条件、发送结果。

全表扫描慢的根源也在这里。

如果表有 100 行,扫一遍没什么。如果表有 1000 万行,每次查询都要大量读取数据页,再一行行判断条件,成本就会非常高。

所以索引解决的核心问题不是“凭空加速”,而是让执行器不用向存储引擎索要那么多无关记录。

九、二级索引和回表:把索引篇的概念放进执行链路

现在看这条查询:

SELECT *
FROM product
WHERE name = 'iPhone';

name 上有二级索引 idx_name,优化器可能选择这个索引。对执行器来说,关键在于二级索引叶子节点保存的是:

二级索引列的值 + 对应主键值

所以在执行阶段,通过 idx_name 找到 name = 'iPhone' 后,InnoDB 只拿到了对应的主键 id,还没有拿到完整的 pricecategory_id 等列。

索引篇已经讲过回表。放到执行流程里看,回表就是执行器按二级索引路径拿到主键后,InnoDB 还要再走一次主键索引,把整行取出来:

二级索引先找到主键,主键索引再找到整行。

路径变成:

通过 idx_name 找到 name = 'iPhone' 的二级索引记录
-> 拿到对应主键 id
-> 用 id 回到主键索引查询整行
-> 返回给执行器

这解释了一个常见现象:用了二级索引,不代表一定很快。命中记录很多、每条都要回表时,成本仍然可能很高。

反过来,如果查询只要二级索引里已经包含的列:

SELECT id
FROM product
WHERE name = 'iPhone';

那么二级索引叶子节点里已经有 id,就不需要回表。执行计划里的 Extra 如果出现 Using index,通常说明查询可以直接从索引中拿到需要的数据。

十、索引下推:把过滤提前到引擎层

索引下推在索引篇里已经解释过。这里换个角度,看它在 Server 层和 InnoDB 之间解决了什么协作问题。

假设有一张用户表:

CREATE TABLE user_profile (
  id BIGINT PRIMARY KEY,
  age INT NOT NULL,
  reward INT NOT NULL,
  nickname VARCHAR(100) NOT NULL,
  KEY idx_age_reward (age, reward)
) ENGINE=InnoDB;

现在查询:

SELECT *
FROM user_profile
WHERE age > 20
  AND reward = 100000;

联合索引是 (age, reward)。在这条访问路径里,age > 20 先确定一段索引范围;后面的 reward 未必还能继续参与精确定位,但它仍然留在索引记录里,供引擎提前判断。

如果没有索引下推,执行过程可能是:

InnoDB 找到一条 age > 20 的二级索引记录
-> 立刻回表拿整行
-> Server 层判断 reward 是否等于 100000
-> 不符合就丢掉
-> 继续下一条

问题是:很多记录可能 age > 20,但 reward 并不等于 100000。如果每条都先回表,再交给 Server 层判断,就会产生大量没必要的回表。

索引下推的思路就是:

既然 reward 本来就在联合索引里,那就让 InnoDB 在二级索引层先判断 reward 条件,符合了再回表。

使用索引下推后,过程变成:

InnoDB 找到一条 age > 20 的二级索引记录
-> 先在索引记录里判断 reward 是否等于 100000
-> 不符合:跳过,不回表
-> 符合:再回表拿整行
-> 返回给 Server 层

这样就能减少回表次数。

执行计划里的 Extra 如果出现 Using index condition,通常说明使用了索引下推。

这里的“下推”不是把 SQL 推到别的机器上,而是把一部分原本由 Server 层判断的条件,推到存储引擎层,在更靠近数据的位置提前过滤。

十一、Server 层和存储引擎层到底怎么分工

到这里,可以把 MySQL 架构分成两层来理解。

Server 层与存储引擎层分工

上图清晰展示了 MySQL 的两层架构。左边 Server 层负责“理解和安排 SQL”,从连接器到执行器,都是通用能力。右边存储引擎层(主要是 InnoDB)负责“真正存取数据”,包括 Buffer Pool、B+ 树、事务、日志和磁盘页。两层之间通过 Handler API 统一接口通信。

Server 层负责通用能力:

连接管理
权限校验
SQL 解析
预处理
查询优化
执行调度
内置函数
视图、触发器、存储过程等跨引擎能力

存储引擎层负责数据怎么存、怎么取:

数据页
B+ 树索引
Buffer Pool
行记录格式
事务

redo log
undo log

这也是为什么 MySQL 可以支持多个存储引擎,比如 InnoDB、MyISAM、Memory。

它们共用 Server 层,但底层存储方式可以不同。

我们平时最常用的是 InnoDB。从 MySQL 5.5 开始,InnoDB 成为默认存储引擎。索引、事务、行锁、MVCC、Buffer Pool 这些内容,主要都发生在 InnoDB 这一层。

所以一条查询不是“Server 层自己查完”,也不是“InnoDB 自己理解 SQL”。

更准确地说:

Server 层负责把 SQL 变成执行计划,并调度执行
InnoDB 负责按执行计划访问真实数据结构

十二、把完整流程串起来

现在回到最开始的 SQL:

SELECT *
FROM product
WHERE id = 1;

它的大致执行流程是:

1. 客户端和 MySQL 建立 TCP 连接
2. 连接器校验用户名、密码,并读取权限
3. MySQL 收到 SQL 文本
4. 解析器做词法分析和语法分析,生成可理解的语法结构
5. 预处理器检查 product 表、id 字段是否存在,并展开 SELECT *
6. 优化器评估访问路径,选择主键索引查询
7. 执行器根据执行计划调用 InnoDB 接口
8. InnoDB 沿主键 B+ 树定位 id = 1 的记录
9. InnoDB 把记录返回给执行器
10. 执行器判断条件成立,把结果返回客户端

如果换成没有索引的条件,后半段就会变成全表扫描。

如果换成二级索引条件,后半段可能会出现回表。

如果换成联合索引加可下推条件,后半段可能会出现索引下推。

也就是说,前半段解决的是:

这条 SQL 能不能执行。

后半段解决的是:

这条 SQL 怎么执行更划算。

十三、慢 SQL 排查时该看哪一段

理解执行流程之后,排查慢 SQL 就不再只是盯着“有没有索引”。

可以沿着执行链逐段看。

第一,看连接层。

如果报的是连接失败、连接数过多、权限错误,就先看:

SHOW PROCESSLIST;
SHOW VARIABLES LIKE 'max_connections';
SHOW VARIABLES LIKE 'wait_timeout';

这类问题还没进入真正的 SQL 执行阶段。

第二,看语法和对象。

如果报语法错误,关注 SQL 写法。

如果报表不存在、字段不存在,关注预处理阶段对应的表结构、库名、权限和环境。

第三,看执行计划。

慢 SQL 最常见的入口是:

EXPLAIN
SELECT ...

重点看:

key 是否用到预期索引
type 是否退化成 ALL
rows 估算扫描行数是否过大
Extra 是否出现 Using filesort、Using temporary、Using index condition 等信息

第四,看存储引擎层。

如果执行计划看起来没问题,但仍然慢,就要继续看:

Buffer Pool 命中率
磁盘 I/O
锁等待
事务隔离级别
回表次数
扫描页数

也就是说,慢 SQL 可能慢在 Server 层,也可能慢在 InnoDB 层。

执行流程的价值,就是让你知道该从哪里开始拆。

十四、最后用一条口诀记住

MySQL 执行一条 SELECT,可以先记成这条线:

先连上
-> 再看懂
-> 再确认
-> 再选路
-> 再执行
-> 最后找引擎拿数据

对应到模块就是:

连接器
-> 解析器
-> 预处理器
-> 优化器
-> 执行器
-> 存储引擎

如果是 MySQL 8.0 之前,还可以在连接器后面补一个历史模块:

查询缓存

但今天更重要的不是背模块名,而是理解每一段为什么存在:

  • 连接器解决“谁能进来”的问题。
  • 解析器解决“这句话语法对不对”的问题。
  • 预处理器解决“表、字段是否真实存在”的问题。
  • 优化器解决“走哪条路成本更低”的问题。
  • 执行器解决“按计划调度执行”的问题。
  • 存储引擎解决“数据到底怎么读出来”的问题。

这样再看 MySQL,你看到的就不再是一串陌生模块,而是一条很自然的执行链:

SQL 是文本,执行计划是路线,存储引擎是真正走到数据页里把记录拿出来的人。